Вы — маркетинговый аналитик стратегической игры "Космические братья".
В рамках исследования необходимо проанализировать поведение пользователей, пришедших из разных рекламных источников и определить, пользователей из каких источников наиболее выгодно привлекать.
Планируемая модель монетизации - показ рекламы в меню выбора типа объекта для постройки.
#блок с импортом всего нужного
import pandas as pd
import seaborn as sns
import datetime as dt
import plotly.express as px
from matplotlib import pyplot as plt
from plotly import graph_objects as go
from scipy import stats as st
from numpy import median, mean
Загрузим датасеты и сразу посмотрим на дубликаты в них
actions = pd.read_csv('/datasets/game_actions.csv')
costs = pd.read_csv('/datasets/ad_costs.csv')
sources = pd.read_csv('/datasets/user_source.csv')
display('Таблица actions =====================================================================================================')
display(actions.head())
display(actions.info())
display('Дубликаты в actions', actions.duplicated().sum())
display('Таблица costs =======================================================================================================')
display(costs.head())
display(costs.info())
display('Дубликаты в costs', costs.duplicated().sum())
display('Таблица sources =====================================================================================================')
display(sources.head())
display(sources.info())
display('Дубликаты в sources', sources.duplicated().sum())
'Таблица actions ====================================================================================================='
| event_datetime | event | building_type | user_id | project_type | |
|---|---|---|---|---|---|
| 0 | 2020-05-04 00:00:01 | building | assembly_shop | 55e92310-cb8e-4754-b622-597e124b03de | NaN |
| 1 | 2020-05-04 00:00:03 | building | assembly_shop | c07b1c10-f477-44dc-81dc-ec82254b1347 | NaN |
| 2 | 2020-05-04 00:00:16 | building | assembly_shop | 6edd42cc-e753-4ff6-a947-2107cd560710 | NaN |
| 3 | 2020-05-04 00:00:16 | building | assembly_shop | 92c69003-d60a-444a-827f-8cc51bf6bf4c | NaN |
| 4 | 2020-05-04 00:00:35 | building | assembly_shop | cdc6bb92-0ccb-4490-9866-ef142f09139d | NaN |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 135640 entries, 0 to 135639 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_datetime 135640 non-null object 1 event 135640 non-null object 2 building_type 127957 non-null object 3 user_id 135640 non-null object 4 project_type 1866 non-null object dtypes: object(5) memory usage: 5.2+ MB
None
'Дубликаты в actions'
1
'Таблица costs ======================================================================================================='
| source | day | cost | |
|---|---|---|---|
| 0 | facebook_ads | 2020-05-03 | 935.882786 |
| 1 | facebook_ads | 2020-05-04 | 548.354480 |
| 2 | facebook_ads | 2020-05-05 | 260.185754 |
| 3 | facebook_ads | 2020-05-06 | 177.982200 |
| 4 | facebook_ads | 2020-05-07 | 111.766796 |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 28 entries, 0 to 27 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 source 28 non-null object 1 day 28 non-null object 2 cost 28 non-null float64 dtypes: float64(1), object(2) memory usage: 800.0+ bytes
None
'Дубликаты в costs'
0
'Таблица sources ====================================================================================================='
| user_id | source | |
|---|---|---|
| 0 | 0001f83c-c6ac-4621-b7f0-8a28b283ac30 | facebook_ads |
| 1 | 00151b4f-ba38-44a8-a650-d7cf130a0105 | yandex_direct |
| 2 | 001aaea6-3d14-43f1-8ca8-7f48820f17aa | youtube_channel_reklama |
| 3 | 001d39dc-366c-4021-9604-6a3b9ff01e25 | instagram_new_adverts |
| 4 | 002f508f-67b6-479f-814b-b05f00d4e995 | facebook_ads |
<class 'pandas.core.frame.DataFrame'> RangeIndex: 13576 entries, 0 to 13575 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 13576 non-null object 1 source 13576 non-null object dtypes: object(2) memory usage: 212.2+ KB
None
'Дубликаты в sources'
0
Полный дубликат только один, в таблице 'actions', просто чтобы убедиться в корректности, посмотрим на него
actions[actions.duplicated(keep=False)]
| event_datetime | event | building_type | user_id | project_type | |
|---|---|---|---|---|---|
| 74890 | 2020-05-10 18:41:56 | building | research_center | c9af55d2-b0ae-4bb4-b3d5-f32aa9ac03af | NaN |
| 74891 | 2020-05-10 18:41:56 | building | research_center | c9af55d2-b0ae-4bb4-b3d5-f32aa9ac03af | NaN |
Просто задублированное действие. Без зазрений совести убираем
actions = actions.drop_duplicates().reset_index(drop=True)
Посмотрим на скрытые дубликаты по всем датасетам
display(actions['event'].unique())
display(actions['building_type'].unique())
display(actions['project_type'].unique())
display(costs['day'].unique())
display(costs['source'].unique())
display(sources['source'].unique())
array(['building', 'finished_stage_1', 'project'], dtype=object)
array(['assembly_shop', 'spaceport', nan, 'research_center'], dtype=object)
array([nan, 'satellite_orbital_assembly'], dtype=object)
array(['2020-05-03', '2020-05-04', '2020-05-05', '2020-05-06',
'2020-05-07', '2020-05-08', '2020-05-09'], dtype=object)
array(['facebook_ads', 'instagram_new_adverts', 'yandex_direct',
'youtube_channel_reklama'], dtype=object)
array(['facebook_ads', 'yandex_direct', 'youtube_channel_reklama',
'instagram_new_adverts'], dtype=object)
Скрытых дубликатов нет, более того, значения столбца 'source' датасета 'costs' соответствуют значения столбца 'source' датасета 'sources'.
И ещё пара проверок просто на всякий случай.
# сколько разных дней соответствует какждому источнику в датасете 'costs'
display(costs.groupby('source').agg({'day': 'nunique'}))
# сколько разных источников соответствует какждому дню в датасете 'costs'
display(costs.groupby('day').agg({'source': 'nunique'}))
# сколько разных источников соответствует какждому пользователю в датасете 'sources', вдруг есть дубли пользователей
display(sources.groupby('user_id').agg({'source': 'count'}).sort_values('source').tail())
| day | |
|---|---|
| source | |
| facebook_ads | 7 |
| instagram_new_adverts | 7 |
| yandex_direct | 7 |
| youtube_channel_reklama | 7 |
| source | |
|---|---|
| day | |
| 2020-05-03 | 4 |
| 2020-05-04 | 4 |
| 2020-05-05 | 4 |
| 2020-05-06 | 4 |
| 2020-05-07 | 4 |
| 2020-05-08 | 4 |
| 2020-05-09 | 4 |
| source | |
|---|---|
| user_id | |
| 53d146cf-1b2a-4038-8326-7900b021f418 | 1 |
| 53d59f09-9745-4bdd-b6e5-56f98a5228d4 | 1 |
| 53d8dfd2-d601-461b-a4fd-890be5b45d27 | 1 |
| 53dd4cec-f4ed-4405-a1e2-5cc272d897ef | 1 |
| ffff69cc-fec1-4fd3-9f98-93be1112a6b8 | 1 |
Данные в датасетах корректные.
Пропуски есть в двух столбцах датасета 'actions': 'building_type' и 'project_type'.
Посмотрим на срез по незаполненным строкам в 'building_type' строкам и какие события им соответствуют.
display(actions.query('building_type != building_type').info())
display(actions.query('building_type != building_type')['event'].unique())
<class 'pandas.core.frame.DataFrame'> Int64Index: 7683 entries, 6659 to 135638 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_datetime 7683 non-null object 1 event 7683 non-null object 2 building_type 0 non-null object 3 user_id 7683 non-null object 4 project_type 1866 non-null object dtypes: object(5) memory usage: 360.1+ KB
None
array(['finished_stage_1', 'project'], dtype=object)
Срез включает в себя все заполненные строки из столбца 'project_type'.
Получается, в этот срез попали события, не связанные со строительством зданий. Посмотрим, работает ли в обратную сторону.
actions.query('event == "project" or event == "finished_stage_1"').info()
<class 'pandas.core.frame.DataFrame'> Int64Index: 7683 entries, 6659 to 135638 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_datetime 7683 non-null object 1 event 7683 non-null object 2 building_type 0 non-null object 3 user_id 7683 non-null object 4 project_type 1866 non-null object dtypes: object(5) memory usage: 360.1+ KB
Срезы друг-другу соответствуют. Предлагаю данные пропуски не трогать - по столбцу 'building_type' будет довольно просто посчитать количество построек, сделанных игроком, а столбец 'project_type' будет хорошим индикатором выполненного проекта.
Скорректируем форматы данных. Корректировка необходима столбцам 'event_datetime' датасета 'actions' и 'day' датасета 'costs'. Второй таже требует приведения к дню.
actions['event_datetime'] = pd.to_datetime(actions['event_datetime'])
costs['day'] = pd.to_datetime(costs['day']).dt.date
display(actions.info())
display(costs.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 135639 entries, 0 to 135638 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_datetime 135639 non-null datetime64[ns] 1 event 135639 non-null object 2 building_type 127956 non-null object 3 user_id 135639 non-null object 4 project_type 1866 non-null object dtypes: datetime64[ns](1), object(4) memory usage: 5.2+ MB
None
<class 'pandas.core.frame.DataFrame'> RangeIndex: 28 entries, 0 to 27 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 source 28 non-null object 1 day 28 non-null object 2 cost 28 non-null float64 dtypes: float64(1), object(2) memory usage: 800.0+ bytes
None
Здесь же хочу проверить информацию по соответствию дат в столбце 'event_datetime' датасета 'actions' и столбце 'day' датасета 'costs'.
display(costs['day'].unique())
display(actions['event_datetime'].min())
array([datetime.date(2020, 5, 3), datetime.date(2020, 5, 4),
datetime.date(2020, 5, 5), datetime.date(2020, 5, 6),
datetime.date(2020, 5, 7), datetime.date(2020, 5, 8),
datetime.date(2020, 5, 9)], dtype=object)
Timestamp('2020-05-04 00:00:01')
Судя по всему, реклама оплачивается на день вперёд, если она оплачена 3 мая, то показы начнутся 4 мая. Чтобы правильно посчитать CAC по игрокам, прибавим в датасете 'costs' всем датам 1 день.
costs['day'] = costs['day'] + dt.timedelta(days=1)
display(costs['day'].unique())
array([datetime.date(2020, 5, 4), datetime.date(2020, 5, 5),
datetime.date(2020, 5, 6), datetime.date(2020, 5, 7),
datetime.date(2020, 5, 8), datetime.date(2020, 5, 9),
datetime.date(2020, 5, 10)], dtype=object)
Создадим профили пользователей, где объединим всю необходимую информацию.
Начнём с информации, касающейся игрового процесса.
# первые столбцы - идентификатор и дата и время первого действия
profiles = (
actions.sort_values(by=['user_id', 'event_datetime'])
.groupby('user_id')
.agg({'event_datetime': 'first', 'building_type': 'count'})
.rename(columns={'event_datetime': 'first_ts', 'building_type': 'build_count'})
.reset_index()
)
# отметим пользователей, завершивших проект и первый этап
profiles['project'] = profiles['user_id'].isin(actions.query('event == "project"')['user_id'].unique())
profiles['stage_1_compl'] = profiles['user_id'].isin(actions.query('event == "finished_stage_1"')['user_id'].unique())
# небольшая функция, чтобы выделить типы игроков
def type_define(flags):
if flags[0] == True and flags[1] == True:
result = 'researcher'
elif flags[1] == True:
result = 'warrior'
else:
result = 'not_completed'
return result
# делим игроков на типы
profiles['user_type'] = profiles[['project', 'stage_1_compl']].apply(type_define, axis=1)
# чтобы узнать, за сколько пользователи завершают первый уровень, присоединим время соответствующего события
profiles = (
profiles.merge(
actions.query('event == "finished_stage_1"')[['user_id', 'event_datetime']],
on='user_id',
how='left',
)
.rename(columns={'event_datetime': 'compl_time'})
)
# узнаем время завершения и переводим в секунды для последующего анализа
profiles['compl_time'] = profiles['compl_time'] - profiles['first_ts']
profiles['compl_time'] = profiles['compl_time'].dt.total_seconds()
# проверим, всё ли хорошо заполнилось и как всё выглядит
profiles.info()
profiles.head()
<class 'pandas.core.frame.DataFrame'> Int64Index: 13576 entries, 0 to 13575 Data columns (total 7 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 13576 non-null object 1 first_ts 13576 non-null datetime64[ns] 2 build_count 13576 non-null int64 3 project 13576 non-null bool 4 stage_1_compl 13576 non-null bool 5 user_type 13576 non-null object 6 compl_time 5817 non-null float64 dtypes: bool(2), datetime64[ns](1), float64(1), int64(1), object(2) memory usage: 662.9+ KB
| user_id | first_ts | build_count | project | stage_1_compl | user_type | compl_time | |
|---|---|---|---|---|---|---|---|
| 0 | 0001f83c-c6ac-4621-b7f0-8a28b283ac30 | 2020-05-06 01:07:37 | 13 | False | False | not_completed | NaN |
| 1 | 00151b4f-ba38-44a8-a650-d7cf130a0105 | 2020-05-06 03:09:12 | 9 | False | False | not_completed | NaN |
| 2 | 001aaea6-3d14-43f1-8ca8-7f48820f17aa | 2020-05-05 18:08:52 | 4 | False | False | not_completed | NaN |
| 3 | 001d39dc-366c-4021-9604-6a3b9ff01e25 | 2020-05-05 21:02:05 | 8 | False | True | warrior | 556722.0 |
| 4 | 002f508f-67b6-479f-814b-b05f00d4e995 | 2020-05-05 13:49:58 | 12 | False | False | not_completed | NaN |
Теперь добавим информацию об источнике, откуда пришёл пользователь и cac.
# выделим день, когда пришёл пользователь
profiles['day'] = profiles['first_ts'].dt.date
# из датасета 'sources' цепляем источник, из которого пришёл пользователь
profiles = profiles.merge(sources, on=['user_id'], how='left')
# для расчёта cac посчитаем, сколько пользователей приходит в день по каналам
users_by_day = (
profiles.groupby(['day', 'source'])
.agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'unique_users'})
.reset_index()
)
# добавим эту информацию в датасет 'costs' и посчитаем cac
costs = costs.merge(users_by_day, on=['day', 'source'], how='left')
costs['cac'] = costs['cost'] / costs['unique_users']
# добавляем в профили
profiles = profiles.merge(
costs[['day', 'source', 'cac']],
on=['day', 'source'],
how='left',
)
# проверим, всё ли хорошо заполнилось и как всё выглядит
profiles.info()
profiles.head()
<class 'pandas.core.frame.DataFrame'> Int64Index: 13576 entries, 0 to 13575 Data columns (total 10 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 13576 non-null object 1 first_ts 13576 non-null datetime64[ns] 2 build_count 13576 non-null int64 3 project 13576 non-null bool 4 stage_1_compl 13576 non-null bool 5 user_type 13576 non-null object 6 compl_time 5817 non-null float64 7 day 13576 non-null object 8 source 13576 non-null object 9 cac 13576 non-null float64 dtypes: bool(2), datetime64[ns](1), float64(2), int64(1), object(4) memory usage: 981.1+ KB
| user_id | first_ts | build_count | project | stage_1_compl | user_type | compl_time | day | source | cac | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0001f83c-c6ac-4621-b7f0-8a28b283ac30 | 2020-05-06 01:07:37 | 13 | False | False | not_completed | NaN | 2020-05-06 | facebook_ads | 0.754162 |
| 1 | 00151b4f-ba38-44a8-a650-d7cf130a0105 | 2020-05-06 03:09:12 | 9 | False | False | not_completed | NaN | 2020-05-06 | yandex_direct | 0.464206 |
| 2 | 001aaea6-3d14-43f1-8ca8-7f48820f17aa | 2020-05-05 18:08:52 | 4 | False | False | not_completed | NaN | 2020-05-05 | youtube_channel_reklama | 0.390759 |
| 3 | 001d39dc-366c-4021-9604-6a3b9ff01e25 | 2020-05-05 21:02:05 | 8 | False | True | warrior | 556722.0 | 2020-05-05 | instagram_new_adverts | 0.631816 |
| 4 | 002f508f-67b6-479f-814b-b05f00d4e995 | 2020-05-05 13:49:58 | 12 | False | False | not_completed | NaN | 2020-05-05 | facebook_ads | 0.790136 |
Думаю, информации достаточно, приступим к анализу.
fig = go.Figure(go.Pie(labels=profiles.groupby('source')['user_id'].count().index, values=profiles.groupby('source')['user_id'].count()))
fig.update_traces(textinfo='value+percent', textposition='inside')
fig.update_layout(title_text='Распределение игроков по источникам',
width=800,
height=600,
annotations=[dict(text='Источники', x=1.18, y=1.05, font_size=15, showarrow=False)])
fig.show()
Больше всего пользователей (немного более трети от всех пользователей) пришло через Яндекс Директ. На втором месте с четвертью пользователей - Instagram, оставшихся пользователей поделили между собой Facebook и Youtube.
Посмотрим, как приходили пользователи по дням.
fig = px.bar(profiles.groupby(['source', 'day'])['user_id'].count().reset_index(), x='day', y='user_id', color='source', text='user_id')
fig.update_layout(title_text='Количество пользователей по дням в разрезе источников',
xaxis_title='Дата',
yaxis_title='Количество пользователей',
width=800,
height=1000,
legend_title_text='Источники')
fig.show()
В течение недели количество привлечённых пользователей заметно снижается, при этом соотношение в количестве пользователей по источникам остаётся похожим.
Возможно, причина такого снижения количества привлечённых пользователей в снижении затрат на рекламу. Проверим это.
fig = px.bar(costs, x='day', y='cost', color='source', text='cost')
fig.update_traces(texttemplate='%{text:.2s}')
fig.update_layout(title_text='Количество пользователей по дням в разрезе источников',
xaxis_title='Дата',
yaxis_title='Количество пользователей',
width=800,
height=1000,
legend_title_text='Источники')
fig.show()
В целом, динамика снижения затрат на рекламу похожа на динамику снижения количества новых пользователей.
Посмотрим на CAC по каналам.
fig = px.line(profiles.sort_values('day'), x='day', y='cac', color='source')
fig.update_layout(title_text='CAC по источникам',
xaxis_title='Дата',
yaxis_title='CAC',
width=800,
height=600,
legend_title_text='Источники')
fig.show()
for source in profiles['source'].unique():
print('Средний CAC по источнику {0}: {1}'.format(source, round(profiles.query('source == @source')['cac'].mean(), 2)))
Средний CAC по источнику facebook_ads: 0.79 Средний CAC по источнику yandex_direct: 0.46 Средний CAC по источнику youtube_channel_reklama: 0.4 Средний CAC по источнику instagram_new_adverts: 0.65
Самым дорогим каналом оказался Facebook, он уверенно держит лидерство и только в последний день его CAC заметно опускается - почти до уровня второго места - Instagram. На третьем месте - Яндекс Директ, а самым дешёвым оказался Youtube.
В целом, все каналы, кроме Facebook имеют довольно стабильный CAC, без резких повышений и понижений.
Небольшое увеличение колебаний ближе к концу закупки скорее всего связаны с тем, что существенно уменьшились затрыты на рекламу по сравнению с началом периода.
Промежуточный вывод
Очень перспективно выглядит канал привлечения Яндекс Директ: через него пришло больше всего пользователей и CAC совсем не высокий.
Youtube тоже выглядит приятно из-за низкого CAC, но самый низкий показатель по количеству привлечённых пользователей не даёт его порекомендовать. Возможно, стоит немного пересмотреть характер рекламы в этом источнике, чтобы привлечь через него больше пользователей.
Точно не хочется рекомендовать Facebook, при низком показателе привлечённых пользователей (чуть выше, чем у Youtube), он обладает самым высоким CAC.
fig = go.Figure(go.Pie(labels=profiles.groupby('user_type')['user_id'].count().index, values=profiles.groupby('user_type')['user_id'].count()))
fig.update_traces(textinfo='value+percent', textposition='inside')
fig.update_layout(title_text='Распределение игроков по типам',
width=800,
height=600,
annotations=[dict(text='Типы игроков', x=1.18, y=1.05, font_size=15, showarrow=False)])
fig.show()
Подавляющее число игроков первый уровень так и не завершило. Не знаю, насколько это типично для игры такого типа, но выглядит настораживающе. Возможно, стоит пересмотреть его наполнение и цели, ставящиеся перед игроком, чтобы он стал немного полегче.
Если говорить о людях, завершивших первый уровень, больше 2/3 человек решили завершить его как "Воители", победив соперника. И только небольшая часть игроков одержала победу через реализацию проекта.
Посмотрим, как типы игроков распределены по источникам привлечения.
fig = px.bar(profiles.groupby(['source', 'user_type'])['user_id'].count().reset_index(), x='source', y='user_id', color='user_type', text='user_id')
fig.update_layout(title_text='Распределение игроков по типам в разрезе источников',
xaxis_title='Источник привлечения',
yaxis_title='Количество пользователей',
width=800,
height=600,
legend_title_text='Типы игроков')
fig.show()
Сильных перекосов по соотношению пользователей разных типов по разным источникам привлечения нет. Пользователи ведут себя одинаково.
Эти соотношения лучше покажет другой график.
fig = px.sunburst(profiles.groupby(['source', 'user_type'])['user_id'].count().reset_index(), path=['source', 'user_type'], values='user_id')
fig.update_layout(title_text='Распределение игроков по типам в разрезе источников',
width=800,
height=600)
Промежуточный вывод
Большинство игроков не завершило первый уровень. Возможно, стоит пересмотреть его наполнение и цели, ставящиеся перед игроком, чтобы он стал немного полегче. Из прошедших первый уровень более 2/3 относятся к типу "Воитель".
В разрезе по каналам практически нет различий в поведении игроков, соотношения типов игроков одинаковые.
Сначала посмотрим, как распределён этот показатель.
print('Всего зданий построено игроками:', profiles['build_count'].sum())
print('В среднем на одного игрока:', round(profiles['build_count'].mean(), 2))
profiles['build_count'].hist(bins=20, figsize=(15, 5))
plt.title('Распределение игроков по количеству постоенных зданий')
plt.xlabel('Количество зданий')
plt.ylabel('Количество игроков')
plt.show()
Всего зданий построено игроками: 127956 В среднем на одного игрока: 9.43
Всего игроки построили почти 128 тысяч зданий, минимум 1, максимум 20. В среднем на одного игрока приходится 9 с половиной зданий.
По графику видно, что игроки чаще всего строят от 9 до 11 зданий, но есть ещё одно часто встречающееся значение - 6 зданий. Между этими значениями - провал на 7 зданиях. Возможно, эти пики сформированы игроками разных типов, проверим это дальше.
В целом, сильных выбросов по количеству зданий нет, данные вглядят готовыми к дальнейшему анализу.
Посмотрим, как выдит картина в зависимости от типа игрока.
for user_type in profiles['user_type'].unique():
print('Всего зданий построено игроками типа {0}: {1}'.format(user_type, profiles.query('user_type == @user_type')['build_count'].sum()))
print('В среднем на одного игрока типа {0}: {1}'.format(user_type, round(profiles.query('user_type == @user_type')['build_count'].mean(), 2)))
profiles.query('user_type == @user_type')['build_count'].hist(bins=20, figsize=(15, 5))
plt.title(user_type)
plt.xlabel("Количество зданий")
plt.ylabel("Количество игроков")
plt.show()
Всего зданий построено игроками типа not_completed: 67170 В среднем на одного игрока типа not_completed: 8.66
Всего зданий построено игроками типа warrior: 37185 В среднем на одного игрока типа warrior: 9.41
Всего зданий построено игроками типа researcher: 23601 В среднем на одного игрока типа researcher: 12.65
Игроки, не прошедшие 1 уровень формируют большинство игроков, постоивших только 6 зданий, после этого идёт провал на 7 зданиях, также как и на общем графике, с последующим небольшгим повышением к 9 зданиям. Их среднее значение построек 8.66 на одного игрока.
У "Воителей" ситуация похожая, но у них пиковое значение игроков остановилось на 9 зданиях, а на 6 так сделала только небольшая часть игроков. Также виден провал на 7 зданиях. Среднее значение построек на единицу больше, чем у не завершивших и равно 9.41.
Складывается ощущение, что на 7 зданиях для игроки проходят "психологический" барьер, на котором они либо бросают игру, либо решают играть дальше.
У "Исследователей" ситуация совсем другая. Игроки, завершившие первый уровень через реализацию проекта строят минимум 10 зданий, при этом большинство игроков останавливаются как раз на 10-12 зданиях, похоже это минимальное значение, нужное для реализации проекта. Среднее значение построек намного больше, чем у двух предыдущих категорий - 12.65.
В рамках планируемой модели монетизации, самыми интересными выглядят именно "Исследователи", но, учитывая что для реализации проекта нужно минимум 10 зданий, стоит пересмотреть геймплей, чтобы сместить "психологический" барьер в 7 зданий на более высокое число.
Давайте посмотрим на медианные значения этих групп игроков.
plt.rcParams['figure.figsize'] = (15,8)
sns.boxplot(data=profiles, x='user_type', y='build_count')
plt.xlabel('Тип игрока')
plt.ylabel('Количество игроков')
plt.title('Медианное количество зданий, построенных игроками разных типов')
plt.show()
Здесь разница заметна лучше. Медианное значение количества построенных зданий у игроков, не завершивших первый уровень, заметно ниже, чем у "Воителей" (8 против 10). "Исследователи" всё также выглядят более перспективно, медианное значение у них также самое высокое - 12.
Рассмотрим количество построенных игроками зданий в разрезе источников привлечения.
for user_source in profiles['source'].unique():
print('Всего зданий построено игроками из {0}: {1}'.format(user_source, profiles.query('source == @user_source')['build_count'].sum()))
print('В среднем на одного игрока из {0}: {1}'.format(user_source, round(profiles.query('source == @user_source')['build_count'].mean(), 2)))
profiles.query('source == @user_source')['build_count'].hist(bins=20, figsize=(15, 5))
plt.title(user_source)
plt.xlabel("Количество зданий")
plt.ylabel("Количество игроков")
plt.show()
Всего зданий построено игроками из facebook_ads: 26131 В среднем на одного игрока из facebook_ads: 9.59
Всего зданий построено игроками из yandex_direct: 45032 В среднем на одного игрока из yandex_direct: 9.35
Всего зданий построено игроками из youtube_channel_reklama: 24978 В среднем на одного игрока из youtube_channel_reklama: 9.3
Всего зданий построено игроками из instagram_new_adverts: 31815 В среднем на одного игрока из instagram_new_adverts: 9.51
Между истониками картина очень похожая, почти идентичная. Количество построенных зданий разнится, но на это скорее влияет общее количество привлечённых через этот источник пользователей: средние значения построенных зданий очень близко друг к другу.
Выделить можно разве что небольшую особенность игроков из источников Facebook и Instagram: у них немного ниже доля игроков, остановившихся на 6 зданиях.
Посмотрим на медианные значения в разрезе источников привлечения.
plt.rcParams['figure.figsize'] = (15,8)
sns.boxplot(data=profiles, x='source', y='build_count')
plt.xlabel('Источник привлечения')
plt.ylabel('Количество построек')
plt.title('Медианное количество построек по источникам привелечения')
plt.show()
Все три графика идентичны. В целом, поведение игроков очень слабо зависит от источника привлечения.
Промежуточный вывод
Количество возводимых игроком зданий больше зависит от того, какие цели он ставит перед собой в игре. "Исследователи", реализующие проект, строят здание явно охотнее других игроков. Но для проекта нужно минимум 10 зданий, в то время как многие игроки не доходят до 7. Думаю, стоит исследовать это барьерное значение и понять, почему игроки могут застревать на этом значении.
Источник привлечения слабо влияет на поведение игрока в части постройки зданий.
Большинство игроков (57,2%) так и не завершили первый уровень игры. Если говорить о людях, завершивших первый уровень, больше 2/3 человек решили завершить его как "Воители", победив соперника. И только небольшая часть игроков одержала победу через реализацию проекта. Причём эти доли в целом сохраняются для всех источников привелечения.
Количество возводимых игроком зданий больше зависит от того, какие цели он ставит перед собой в игре. "Исследователи", реализующие проект, строят здание явно охотнее других игроков. Но для проекта нужно минимум 10 зданий, в то время как многие игроки не доходят до 7. Источник привлечения слабо влияет на поведение игрока в части постройки зданий.
Возможно, стоит пересмотреть наполнение и цели, ставящиеся перед игроком, во время прохождения первого уровня игры чтобы он стал немного полегче. Также, стоит исследовать барьерное значение в 7 построек и понять, почему игроки могут застревать на этом значении.
Среди каналов привлечения хочу отметить Яндекс Директ: через него пришло больше всего пользователей и CAC совсем не высокий (среднее значение 0,46). Youtube тоже выглядит приятно из-за низкого CAC, но самый низкий показатель по количеству привлечённых пользователей не даёт его однозначно порекомендовать. Возможно, стоит немного пересмотреть характер рекламы в этом источнике, чтобы привлечь через него больше пользователей.
Точно не хочется рекомендовать Facebook, при низком показателе привлечённых пользователей (чуть выше, чем у Youtube), он обладает самым высоким CAC (среднее значение 0,79).
Перед сравнение времени прохождения первого уровня, посмотрим, как распределены значения времени для типов игроков "Воитель" и "Исследователь".
for user_type in ['warrior', 'researcher']:
profiles.query('user_type == @user_type')['compl_time'].hist(bins=100, figsize=(15, 5))
plt.title(user_type)
plt.xlabel("Время прохождения")
plt.ylabel("Количество игроков")
plt.show()
Обе выборки нормально распределены и не имеют огромных выбросов, думаю, можно начать анализ без изменений.
Примем нулевую гипотезу - среднее время завершения первого уровня не различается для типов игрока "Воитель" и "Исследователь".
Уровень alpha принимаю за 0.05, проверка проводится с помощью t-теста. Параметр equal_var установим в значение False так как сравниваемые выборки не равны между собой.
display('P-value: {0:.3f}'.format(st.ttest_ind(profiles.query('user_type == "warrior"')['compl_time'], profiles.query('user_type == "researcher"')['compl_time'], equal_var=False).pvalue))
display('Превышение среднего времени завершения 1 уровня "Исследователей" над "Воителями": {0:.2%}'.format(profiles.query('user_type == "researcher"')['compl_time'].mean() / profiles.query('user_type == "warrior"')['compl_time'].mean() - 1))
'P-value: 0.000'
'Превышение среднего времени завершения 1 уровня "Исследователей" над "Воителями": 21.20%'
Отвергаем нулевую гипотезу. P-value фактически равно нулю. В среднем, "Исследователи" завершают 1 уровень на 21.20% дольше, чем "Воители".
Распределение количества зданий рассматривали выше, можно считать, что выборки распределены нормально и и не имеют огромных выбросов, думаю, можно начать анализ без изменений.
Примем нулевую гипотезу - количество построек, сделанных игроками не отличается для типов игрока "Воитель" и "Исследователь".
Уровень alpha принимаю за 0.05, проверка проводится с помощью t-теста. Параметр equal_var установим в значение False так как сравниваемые выборки не равны между собой.
display('P-value: {0:.3f}'.format(st.ttest_ind(profiles.query('user_type == "warrior"')['build_count'], profiles.query('user_type == "researcher"')['build_count'], equal_var=False).pvalue))
display('Превышение среднего количества построек "Исследователей" над "Воителями": {0:.2%}'.format(profiles.query('user_type == "researcher"')['build_count'].mean() / profiles.query('user_type == "warrior"')['build_count'].mean() - 1))
'P-value: 0.000'
'Превышение среднего количества построек "Исследователей" над "Воителями": 34.39%'
Отвергаем нулевую гипотезу. P-value равно нулю. В среднем, "Исследователи" строят на 34.39% больше, чем "Воители".
Группы игроков "Исследователи" и "Воители" очень сильно различаются между собой. Цели, которое ставят перед собой "Исследователи", стимулируют их строить сильно большее количество зданий, также, увеличивают время, за которое они проходят 1 уровень.
Поведение игроков слабо зависит от источника, через который они пришли. Поэтому для его рекомендации можно опираться только на показатели количества привлечённых пользователей и CAC. Самым перспективным по сочетанию этих показателей выглядит Яндекс Директ: через него пришло больше всего пользователей (35,5% от общего числа) и CAC совсем не высокий, среднее значение 0,46. Дешевле только один источник - Youtube, но у него самый низкий показатель по количеству привлечённых пользователей. Возможно, стоит немного пересмотреть характер рекламы в этом источнике, чтобы привлечь через него больше пользователей.
Группы игроков "Исследователи" (завершившие проект) и "Воители" (подебившие противника) очень сильно различаются между собой. Цели, которое ставят перед собой "Исследователи", стимулируют их строить сильно большее количество зданий (среднее количество - 12.65 на игрока), также, увеличивают время, за которое они проходят 1 уровень.
Большинство игроков (57,2%) так и не завершили первый уровень игры. Также, многие игроки "застряли" на постройке 6 зданий и не стали строить больше. Возможно, стоит пересмотреть наполнение и цели, ставящиеся перед игроком, во время прохождения первого уровня игры чтобы он стал немного полегче. Также, стоит исследовать барьерное значение в 7 построек и понять, почему игроки могут застревать на этом значении.